Python ジェネレータ - イテレータから協調型マルチタスクまで - 2
著者:Leonardo Giordani - 26/03/2013
はじめに
Pythonでの反復処理の復習の後、この記事では、イテレータの使用から生じるいくつかの問題を解決することを目的とした、ジェネレータの概念を紹介します。
ジェネレータ
for構文は一般的に使いやすく、このようなループはほとんどすべてのプログラミング言語で見つけることができます。しかし、その実装には問題がある場合もあります。その例を見てみましょう。
code: python
def sequence(num):
s = []
i = 0
while i != num:
s.append(i)
i += 1
return s
for i in sequence(5):
print i
一見すると、sequence()は大きな欠陥のない良い関数のように見えます(単純化のためにエラーチェックは意図的に省略されており、例えば負数では動作しませんし、コードも意図的にPytnonicなものになっていません)。
このようなコードに隠された問題は、リスト全体が構築されるまで関数がリターンを実行せず、関数がリターンするまでループが開始されないことです。したがって、ループが始まったときには、関数はすでにデータセット全体を処理していることになります。
通常の数列を構築している間は、たとえ長い数列であっても、これは関係ないと考えることができます。この問題は、データセットが非常に大きくなった場合や、要素の作成が非常に負荷の高い処理である場合に悪化します。最初のケースでは、関数がメモリを埋め尽くすかもしれませんが、2番目のケースでは、実行全体が非常に長くなります。これらの状況は,ループが最初の要素を生成する前にも起こります.
この問題を解決する方法は、生成(Generation) という概念にあります。ここでいう「生成」とは,関数を呼び出すたびに,シーケンスの要素を1つだけ生成することです.これにより、各呼び出しで要素を生成するのに必要なメモリとCPUの時間が最小限になり、すぐにループが開始されます。
グローバル変数を使用せずにこのようなソリューションを実装するために、Pythonにはジェネレータ(Generator) が導入されています。ジェネレータは特別なタイプのイテレータで、その特徴は構築方法にあります。この点を除けば、ジェネレータはイテレータと全く同じように動作します。
yieldの動作はreturnと同じです。つまり、関数を終了して呼び出し元に値を返します。しかし、returnが関数を永久に終了させ、ローカル変数をガベージコレクタに渡してしまうのに対し、yieldは関数のコードを凍結し、後から呼び出されたコードがyieldの直後で、すべてのローカル変数を前回の実行時と同じように初期化して実行を再開できるようにします。
yield文を含む関数の最初の呼び出しは、関数のコードを1行も実行せずに、直ちにジェネレータを返すという事実に注目してください。ジェネレータはイテレータなので、自動的にnext()メソッドが公開され、これが呼ばれると、凍結された関数の実行が実際に継続されます。
ジェネレータの簡単な例を以下に示します。
code: python
def dec(num):
x = num
while 1:
x -= 1
yield x
code: python
>> g = dec(8)
>> g
<generator object dec at 0xb6abbf2c>
dec() 関数が実行されると、ジェネレータオブジェクトが返され、コードは何行も実行されていません(つまり、x はまだ初期化されていません)。
next() が初めて呼ばれるとすぐに、x は関数に渡された値(上記の例では8)で初期化され、無限ループが開始されます。無限ループの中では、ローカル変数xがデクリメントされ、yieldによって呼び出し元に返されます。yieldによってコードが再び凍結され、内部状態(この場合は変数xの値)が保持されます。
code: python
>> g.next()
7
>> g.next()
6
>> g.next()
5
>> g.next()
4
ご覧のように、ジェネレータ g は以前の状態を記憶し、それに従って動作します。
ジェネレータはイテレータなので、使い果たしたことを知らせるためにStopIteration例外を発生させることができます。先ほどの例では dec() 関数が例外を発生させなかったので、無限のジェネレータ(または無限のイテレータ)を提供しています。
もう1つの注意点は、returnに関しては、関数は1つのステートメントに限定されず、複数のステートメントを含むことができます。明らかに、yieldの特殊性は、このシナリオをかなり複雑にしていますが、同時に、ステートマシンを簡単に構築するような顕著な可能性を開いています。
パラメータの値を変えて再度関数を呼び出すとどうなるでしょうか?同じように動作しますが、最初のものとは完全に独立した新しいジェネレータオブジェクトが得られるだけです。
code: python
>> f = dec(12)
>> f
<generator object dec at 0xb72e1cac>
>> f.next()
11
>> g.next()
3
>> f.next()
10
>> g.next()
2
全体のコンセプトをおさらいしましょう。yield 文を含む関数はジェネレータ関数と呼ばれ、実行されるとジェネレータオブジェクトを返します。これは単なるイテレータに過ぎず、関数のコードのフリーズとレジュームを自動的に実装します。
ジェネレータはイテレータであるという事実に注意してください(next() と __iter__() メソッドを公開しており、StopIterationを発生させることがあります)。ジェネレータとは、ジェネレータ関数によって作られたイテレータのことで、yield文を使って作られます。
先ほどの sequence() 関数の話に戻りますが、これをジェネレータ関数として書くと、任意の長さの配列を作ることができ、極限では無限大になります。
code: python
def sequence(num):
i = 0
while 1:
if i == num:
raise StopIteration
yield i
i += 1
code: python
>> s = sequence(3)
>> s.next()
0
>> s.next()
1
>> s.next()
2
>> s.next()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in sequence
StopIteration
>>
>> for i in sequence(3):
... print i
...
0
1
2
ジェネレータとイテレータ
ジェネレータがイテレータであるならば、なぜ yield-ベースメソッドを使ってジェネレータを作成しなければならないのか、という疑問が生じるかもしれません。結局のところ、ジェネレータ関数は書くのは簡単ですが、標準的な関数とは根本的に異なる動作のため、管理はそれほど簡単ではありません。next()メソッドが呼び出されるたびに、シーケンスの正しい要素を生成するイテレート可能なオブジェクトを作成すれば十分ではないでしょうか?
ジェネレータでできることはすべて、標準的なイテレータでもできます。しかし、2つの注意点があります。
オブジェクトをインスタンス化してそのメソッドを呼び出すことは、関数を呼び出すことよりも遅いのです。パフォーマンスの問題を聞くたびに、Webサービス、管理ツール、科学ツール、そして一般的には大量のデータについて考えるでしょう。明らかに、十数個のファイルを管理するスクリプトは、そのような問題に顕著な影響を受けません。
次に、コードの複雑さについて考えてみましょう。関数を書くことはオブジェクトを書くことよりも簡単です。しかし、反復可能なオブジェクトは、カスタムメソッドによって強化され、ジェネレータよりも柔軟性が高くなることを考慮してください。
ジェネレータ式
上記の2つの見解は、ジェネレータがイテレータよりも単純な方法で解決できる問題を示しています。これらの問題の1つは、長いデータ配列の処理に関するものです。
すべてのPythonプログラマーは、リスト内包表記の優雅さを利用しています。次のコードは、MyObjectクラスの100個のオブジェクトを1行でインスタンス化し、リストに格納します。
code: python
これは旧来の記述では次のコードになります。
code: python
object_list = []
for i in range(100):
object_list.append(MyObject())
確かにコード量は多くありませんが、すぐに理解することができず、エレガントさに欠ける、一言で言えばPythonicではありません。しかし、リスト内包は、上記のforループを表現するための代替構文に過ぎず、同じ問題を抱えており、特に先ほど話したパフォーマンスの問題があります。リスト内包の苦手なものは、長いリストと、生成にコストがかかるオブジェクトです。
このような場合、ジェネレータを利用することができるでしょうか?はい、ジェネレータ式があります。このような式の構文は、角括弧の代わりに丸括弧を使うことを除いて、リスト内包の構文と同じです。しかし、リスト内包表記がリストを返すのに対し、ジェネレータ式はその名の通りジェネレータを返します。前述のコードは次のように書くことができます。
code: python
object_generator = (MyObject() for i in range(100))
ここで、object_generator は、ジェネレータ関数が返すジェネレータのようなものです。後者のコードは、次のようになります。
code: python
def object_generator_function():
for i in range(100):
yield MyObject()
object_generator = object_generator_function()
これは、リスト内包表記やforループの場合と同様に、同等のショートカット構文よりもエレガントではありません。ジェネレータ式が実行された後、まだ要素が生成されていないという点では、ジェネレータを返すことには上記のような利点があります。これは、ジェネレータがforループや同様の構造で消費されたときに起こります。
リスト内包の場合、ジェネレータ式は次のような形式の条件を含むことができます。
code: python
generator = (expression for i in s if condition).
また、1引数の関数の引数として直接使用することもできます。その場合は、関数呼び出し用の括弧を使って式を表します。
code: python
afunction(expression for i in s)
しかし,このような構文を使っても,関数に渡す前にジェネレータを使い切ってしまうので,実際のパフォーマンスの向上はありませんが,この構文は非常にエレガントでコンパクトです.
同じ長さの2つのリスト(1つはキー、もう1つは値)から、以下のような辞書を得ることができます。
code: python
d = dict(z for z in zip(keys, values))
なぜなら、dict() は (key, value) タプルのイテレート可能なものを受け入れ、それは zip() を使ったジェネレータが返すものだからです。
まとめ
ジェネレータは非常に強力なツールです。イテレータの作成を簡単にするだけでなく、リスト内のオブジェクトの作成を遅らせることができるという利点や、ジェネレータ関数が実行を中断したり再開したりすることができるという利点もあります。第3回目の記事でご紹介するように、この最後の機能は、協調的マルチタスクへの簡単なアプローチの基礎となるものです。
Part 2 of the Python generators - from iterators to cooperative multitasking series